C.S.Burner 🪙 ━►🔥GIT Node
Commit 8dd7e2eaa60253ebc2fb44bc18ea43f7f9fb77a0
Parents : e11932a
Author : Ivan <e318cbc04468bd574db2b4523dddd710>
Signature : T66BB85Valid, signed by author
Date : 2026-09-12T08:47:11-05:00
refactor(micron): extract publish-to-mesh state into useMicronPublish
Move the publish cluster (publish menu/modal state, page-node list,
publish busy, publishToNode/createMeshServerAndPublish/publishSite and
page-base helpers) into js/micron/useMicronPublish.js bound via setup()
merge; $t, $router.push, active tab, and uploadImages arrive via options.
Changes
3 files changed, 702 insertions(+), 354 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
index 5dc52275..a653382e 100644
--- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
+++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
@@ -258,6 +258,7 @@
</template>
<script>
+import { getCurrentInstance } from "vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MicronParser from "../../js/MicronParser.js";
import { micronStorage } from "../../js/MicronStorage";
@@ -270,11 +271,11 @@ import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
import PublishSiteModal from "./PublishSiteModal.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
-import { apiPath, EMITTER_EVENTS } from "../../js/constants.js";
+import { EMITTER_EVENTS } from "../../js/constants.js";
import * as pageNodesApi from "../../js/api/pageNodes.js";
+import { useMicronPublish } from "../../js/micron/useMicronPublish.js";
const NOMAD_DESTINATION_HASH = /^[a-fA-F0-9]{32}$/;
-const PAGE_EXTENSIONS = [".mu", ".html", ".md", ".txt"];
const IMAGE_MIME_EXTENSIONS = {
"image/png": ".png",
@@ -292,10 +293,6 @@ const MAX_EDITOR_IMAGE_BYTES = 8 * 1024 * 1024;
// :/file/name.webp. A leading destination hash makes them remote.
const LOCAL_IMAGE_REF_REGEX = /(?<![0-9a-fA-F]):\/(media|file)\/([A-Za-z0-9._-]+)/g;
-function pageNamesFromList(pages) {
- return (pages || []).map((entry) => (typeof entry === "string" ? entry : entry?.name)).filter(Boolean);
-}
-
export default {
name: "MicronEditorPage",
components: {
@@ -303,6 +300,17 @@ export default {
ToolsPageHeader,
PublishSiteModal,
},
+ setup() {
+ const inst = getCurrentInstance();
+ return {
+ ...useMicronPublish({
+ t: (key, params) => inst?.proxy.$t(key, params),
+ pushRoute: (payload) => inst?.proxy.$router.push(payload),
+ getActiveTab: () => inst?.proxy.tabs[inst?.proxy.activeTabIndex],
+ uploadImages: (node, contents) => inst?.proxy.uploadLocalImagesToNode(node, contents),
+ }),
+ };
+ },
data() {
return {
tabs: [],
@@ -313,13 +321,8 @@ export default {
storageKey: "micron_editor_content",
editingTabIndex: -1,
editingTabName: "",
- showPublishMenu: false,
- showPublishSiteModal: false,
dragTabIndex: -1,
dragOverTabIndex: -1,
- pageNodes: [],
- publishBusy: false,
- lastPublished: null,
useWasm: false,
wasmReady: false,
wasmBundled: isMicronWasmBundled(),
@@ -1500,349 +1503,6 @@ ${b}=
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
- async togglePublishMenu() {
- this.showPublishMenu = !this.showPublishMenu;
- if (this.showPublishMenu) {
- try {
- const response = await window.api.get(apiPath("/page-nodes"));
- this.pageNodes = response.data;
- } catch {
- this.pageNodes = [];
- }
- }
- },
- async fetchNodePages(node) {
- const response = await window.api.get(apiPath(`/page-nodes/${node.node_id}/pages`));
- return response.data?.pages ?? [];
- },
- tabNameToPageBase(tab) {
- let name = (tab.name || "").trim().replace(/\s+/g, "_");
- const lower = name.toLowerCase();
- for (const ext of PAGE_EXTENSIONS) {
- if (lower.endsWith(ext)) {
- return name.slice(0, -ext.length);
- }
- }
- return name;
- },
- pageBaseWithExtension(base, tab) {
- const trimmed = String(base || "").trim();
- if (!trimmed) {
- return trimmed;
- }
- const lower = trimmed.toLowerCase();
- for (const ext of PAGE_EXTENSIONS) {
- if (lower.endsWith(ext)) {
- return trimmed;
- }
- }
- const tabName = (tab?.name || "").trim();
- const tabLower = tabName.toLowerCase();
- for (const ext of PAGE_EXTENSIONS) {
- if (tabLower.endsWith(ext)) {
- return `${trimmed}${ext}`;
- }
- }
- return trimmed;
- },
- isUnsetMicronTabName(name) {
- const trimmed = (name || "").trim();
- if (!trimmed) {
- return true;
- }
- const newTabLabel = this.$t("tools.micron_editor.new_tab");
- if (trimmed === newTabLabel) {
- return true;
- }
- const numberedPrefix = `${newTabLabel} `;
- if (!trimmed.startsWith(numberedPrefix)) {
- return false;
- }
- return /^\d+$/.test(trimmed.slice(numberedPrefix.length));
- },
- async resolvePublishPageBase(tab, existingPages, serverName) {
- const pageNames = pageNamesFromList(existingPages);
- const hasIndex = pageNames.includes("index.mu");
- if (!hasIndex) {
- return "index";
- }
- if (!this.isUnsetMicronTabName(tab.name)) {
- const base = this.tabNameToPageBase(tab);
- return base || null;
- }
- const entered = await DialogUtils.prompt(
- this.$t("tools.micron_editor.publish_prompt_name", { server: serverName })
- );
- if (entered === null || !String(entered).trim()) {
- return null;
- }
- let base = String(entered).trim().replace(/\s+/g, "_");
- const lower = base.toLowerCase();
- for (const ext of PAGE_EXTENSIONS) {
- if (lower.endsWith(ext)) {
- return base;
- }
- }
- return base || null;
- },
- async ensureNodeRunning(node) {
- if (node?.running && node.destination_hash) {
- return node;
- }
- if (!node?.node_id) {
- throw new Error("missing_node");
- }
- const startRes = await window.api.post(apiPath(`/page-nodes/${node.node_id}/start`));
- const destinationHash = startRes.data?.destination_hash || node.destination_hash || "";
- return {
- ...node,
- running: true,
- destination_hash: destinationHash,
- };
- },
- nomadPagePathForName(pageName) {
- const name = String(pageName || "index.mu").trim();
- if (!name) {
- return "/page/index.mu";
- }
- if (name.startsWith("/page/")) {
- return name;
- }
- if (name.startsWith("/")) {
- return name;
- }
- return `/page/${name}`;
- },
- rememberPublished(node, pageName) {
- const destinationHash = (node?.destination_hash || "").trim();
- if (!destinationHash || !NOMAD_DESTINATION_HASH.test(destinationHash)) {
- return;
- }
- this.lastPublished = {
- destinationHash,
- pagePath: this.nomadPagePathForName(pageName),
- pageName: pageName || "index.mu",
- serverName: node.name || "",
- };
- },
- openPublishedInNomadNet() {
- const published = this.lastPublished;
- if (!published?.destinationHash) {
- return;
- }
- this.showPublishMenu = false;
- this.$router.push({
- name: "nomadnetwork",
- params: { destinationHash: published.destinationHash },
- query: {
- path: published.pagePath || "/page/index.mu",
- newTab: "1",
- },
- });
- },
- async offerOpenInNomadNet(pageName, serverName) {
- if (!this.lastPublished?.destinationHash) {
- DialogUtils.alert(
- this.$t("tools.micron_editor.publish_published", { page: pageName, server: serverName })
- );
- return;
- }
- const open = await DialogUtils.confirm(
- this.$t("tools.micron_editor.publish_open_nomadnet_confirm", {
- page: pageName,
- server: serverName,
- })
- );
- if (open) {
- this.openPublishedInNomadNet();
- } else {
- ToastUtils.success(
- this.$t("tools.micron_editor.publish_published", { page: pageName, server: serverName })
- );
- }
- },
- async createMeshServerAndPublish() {
- if (this.publishBusy) {
- return;
- }
- const entered = await DialogUtils.prompt(
- this.$t("tools.micron_editor.publish_create_prompt_name"),
- "Micron Pages"
- );
- if (entered === null || !String(entered).trim()) {
- return;
- }
- const serverName = String(entered).trim();
- this.publishBusy = true;
- try {
- const createRes = await window.api.post(apiPath("/page-nodes"), { name: serverName });
- const created = createRes.data || {};
- if (!created.node_id) {
- throw new Error("create_failed");
- }
- const running = await this.ensureNodeRunning(created);
- this.pageNodes = [...this.pageNodes.filter((n) => n.node_id !== running.node_id), running];
- await this.publishToNode(running, { alreadyRunning: true });
- } catch (e) {
- this.showPublishMenu = false;
- DialogUtils.alert(e.response?.data?.message || this.$t("tools.micron_editor.publish_failed_create"));
- } finally {
- this.publishBusy = false;
- }
- },
- async publishToNode(node, options = {}) {
- if (this.publishBusy && !options.alreadyRunning) {
- return;
- }
- const tab = this.tabs[this.activeTabIndex];
- const busyOwned = !options.alreadyRunning;
- if (busyOwned) {
- this.publishBusy = true;
- }
- try {
- let running = node;
- if (!options.alreadyRunning) {
- running = await this.ensureNodeRunning(node);
- }
- const existingPages = await this.fetchNodePages(running);
- const pageBase = await this.resolvePublishPageBase(tab, existingPages, running.name);
- if (!pageBase) {
- return;
- }
- const publishName = this.pageBaseWithExtension(pageBase, tab);
- const response = await window.api.post(apiPath(`/page-nodes/${running.node_id}/pages`), {
- name: publishName,
- content: tab.content,
- });
- this.showPublishMenu = false;
- const savedName = response.data?.name || publishName;
- await this.uploadLocalImagesToNode(running, [tab.content]);
- this.rememberPublished(running, savedName);
- await this.offerOpenInNomadNet(savedName, running.name);
- } catch (e) {
- DialogUtils.alert(
- e.response?.data?.message ||
- (e.message === "missing_node"
- ? this.$t("tools.micron_editor.publish_failed")
- : this.$t("tools.micron_editor.publish_failed"))
- );
- } finally {
- if (busyOwned) {
- this.publishBusy = false;
- }
- }
- },
- async openPublishSite() {
- this.showPublishMenu = false;
- this.showPublishSiteModal = true;
- try {
- const response = await window.api.get(apiPath("/page-nodes"));
- this.pageNodes = response.data;
- } catch {
- this.pageNodes = [];
- }
- },
- buildSiteIndexPage(destinationHash, pages) {
- const lines = [`>Links`, ""];
- for (const page of pages) {
- const label = String(page.label || page.name || "")
- .replace(/[`[\]]/g, "")
- .trim();
- const pageName = String(page.name || "").trim();
- if (!pageName) {
- continue;
- }
- lines.push(`[${label || pageName}\`${destinationHash}:/page/${pageName}]`);
- }
- lines.push("");
- return lines.join("\n");
- },
- async publishSite(payload) {
- if (this.publishBusy) {
- return;
- }
- const pages = payload?.pages || [];
- if (pages.length === 0) {
- return;
- }
- this.publishBusy = true;
- try {
- let node = payload.nodeId ? this.pageNodes.find((n) => n.node_id === payload.nodeId) : null;
- if (!node) {
- if (!payload.newServerName) {
- DialogUtils.alert(this.$t("tools.micron_editor.publish_site_no_server"));
- return;
- }
- const createRes = await window.api.post(apiPath("/page-nodes"), { name: payload.newServerName });
- node = createRes.data || {};
- if (!node.node_id) {
- throw new Error("create_failed");
- }
- }
- const running = await this.ensureNodeRunning(node);
- this.pageNodes = [...this.pageNodes.filter((n) => n.node_id !== running.node_id), running];
- let published = 0;
- let lastSavedName = null;
- for (const page of pages) {
- try {
- const response = await window.api.post(apiPath(`/page-nodes/${running.node_id}/pages`), {
- name: page.name,
- content: page.content,
- });
- lastSavedName = response.data?.name || page.name;
- published++;
- } catch {
- console.error(`Failed to publish page: ${page.name}`);
- }
- }
- await this.uploadLocalImagesToNode(
- running,
- pages.map((page) => page.content)
- );
- if (payload.generateIndex && running.destination_hash && published > 0) {
- try {
- const indexContent = this.buildSiteIndexPage(running.destination_hash, pages);
- const indexRes = await window.api.post(apiPath(`/page-nodes/${running.node_id}/pages`), {
- name: "index.mu",
- content: indexContent,
- });
- lastSavedName = indexRes.data?.name || "index.mu";
- } catch {
- console.error("Failed to publish generated index page");
- }
- }
- this.showPublishSiteModal = false;
- if (lastSavedName) {
- this.rememberPublished(running, lastSavedName);
- }
- if (published === 0) {
- DialogUtils.alert(this.$t("tools.micron_editor.publish_failed"));
- return;
- }
- ToastUtils.success(
- this.$t("tools.micron_editor.publish_site_done", {
- published,
- total: pages.length,
- server: running.name,
- })
- );
- if (this.lastPublished?.destinationHash) {
- const open = await DialogUtils.confirm(
- this.$t("tools.micron_editor.publish_open_nomadnet_confirm", {
- page: lastSavedName,
- server: running.name,
- })
- );
- if (open) {
- this.openPublishedInNomadNet();
- }
- }
- } catch (e) {
- DialogUtils.alert(e.response?.data?.message || this.$t("tools.micron_editor.publish_failed"));
- } finally {
- this.publishBusy = false;
- }
- },
},
};
</script>
diff --git a/meshchatx/src/frontend/js/micron/useMicronPublish.js b/meshchatx/src/frontend/js/micron/useMicronPublish.js
new file mode 100644
index 00000000..4234bfff
--- /dev/null
+++ b/meshchatx/src/frontend/js/micron/useMicronPublish.js
@@ -0,0 +1,413 @@
+// @ts-check
+
+import { ref } from "vue";
+
+import DialogUtils from "../DialogUtils";
+import ToastUtils from "../ToastUtils";
+import { apiPath } from "../constants.js";
+
+const NOMAD_DESTINATION_HASH = /^[a-fA-F0-9]{32}$/;
+const PAGE_EXTENSIONS = [".mu", ".html", ".md", ".txt"];
+
+function pageNamesFromList(pages) {
+ return (pages || []).map((entry) => (typeof entry === "string" ? entry : entry?.name)).filter(Boolean);
+}
+
+/**
+ * Publish-to-mesh-server state for MicronEditorPage: the publish dropdown,
+ * site modal, page node list, busy flag and last-published marker, plus the
+ * page/site publish flows.
+ *
+ * options.t is the host $t for dialog and toast strings. options.pushRoute
+ * performs the $router.push for "open in NomadNet". options.getActiveTab
+ * returns the host's active editor tab. options.uploadImages delegates to
+ * the host's local image upload so referenced media reach the node.
+ */
+export function useMicronPublish(options = {}) {
+ const {
+ t = (key) => key,
+ pushRoute = () => {},
+ getActiveTab = () => undefined,
+ uploadImages = () => Promise.resolve(),
+ } = options;
+
+ const showPublishMenu = ref(false);
+ const showPublishSiteModal = ref(false);
+ const pageNodes = ref([]);
+ const publishBusy = ref(false);
+ const lastPublished = ref(null);
+
+ async function togglePublishMenu() {
+ showPublishMenu.value = !showPublishMenu.value;
+ if (showPublishMenu.value) {
+ try {
+ const response = await window.api.get(apiPath("/page-nodes"));
+ pageNodes.value = response.data;
+ } catch {
+ pageNodes.value = [];
+ }
+ }
+ }
+
+ async function fetchNodePages(node) {
+ const response = await window.api.get(apiPath(`/page-nodes/${node.node_id}/pages`));
+ return response.data?.pages ?? [];
+ }
+
+ function tabNameToPageBase(tab) {
+ let name = (tab.name || "").trim().replace(/\s+/g, "_");
+ const lower = name.toLowerCase();
+ for (const ext of PAGE_EXTENSIONS) {
+ if (lower.endsWith(ext)) {
+ return name.slice(0, -ext.length);
+ }
+ }
+ return name;
+ }
+
+ function pageBaseWithExtension(base, tab) {
+ const trimmed = String(base || "").trim();
+ if (!trimmed) {
+ return trimmed;
+ }
+ const lower = trimmed.toLowerCase();
+ for (const ext of PAGE_EXTENSIONS) {
+ if (lower.endsWith(ext)) {
+ return trimmed;
+ }
+ }
+ const tabName = (tab?.name || "").trim();
+ const tabLower = tabName.toLowerCase();
+ for (const ext of PAGE_EXTENSIONS) {
+ if (tabLower.endsWith(ext)) {
+ return `${trimmed}${ext}`;
+ }
+ }
+ return trimmed;
+ }
+
+ function isUnsetMicronTabName(name) {
+ const trimmed = (name || "").trim();
+ if (!trimmed) {
+ return true;
+ }
+ const newTabLabel = t("tools.micron_editor.new_tab");
+ if (trimmed === newTabLabel) {
+ return true;
+ }
+ const numberedPrefix = `${newTabLabel} `;
+ if (!trimmed.startsWith(numberedPrefix)) {
+ return false;
+ }
+ return /^\d+$/.test(trimmed.slice(numberedPrefix.length));
+ }
+
+ async function resolvePublishPageBase(tab, existingPages, serverName) {
+ const pageNames = pageNamesFromList(existingPages);
+ const hasIndex = pageNames.includes("index.mu");
+ if (!hasIndex) {
+ return "index";
+ }
+ if (!isUnsetMicronTabName(tab.name)) {
+ const base = tabNameToPageBase(tab);
+ return base || null;
+ }
+ const entered = await DialogUtils.prompt(t("tools.micron_editor.publish_prompt_name", { server: serverName }));
+ if (entered === null || !String(entered).trim()) {
+ return null;
+ }
+ let base = String(entered).trim().replace(/\s+/g, "_");
+ const lower = base.toLowerCase();
+ for (const ext of PAGE_EXTENSIONS) {
+ if (lower.endsWith(ext)) {
+ return base;
+ }
+ }
+ return base || null;
+ }
+
+ async function ensureNodeRunning(node) {
+ if (node?.running && node.destination_hash) {
+ return node;
+ }
+ if (!node?.node_id) {
+ throw new Error("missing_node");
+ }
+ const startRes = await window.api.post(apiPath(`/page-nodes/${node.node_id}/start`));
+ const destinationHash = startRes.data?.destination_hash || node.destination_hash || "";
+ return {
+ ...node,
+ running: true,
+ destination_hash: destinationHash,
+ };
+ }
+
+ function nomadPagePathForName(pageName) {
+ const name = String(pageName || "index.mu").trim();
+ if (!name) {
+ return "/page/index.mu";
+ }
+ if (name.startsWith("/page/")) {
+ return name;
+ }
+ if (name.startsWith("/")) {
+ return name;
+ }
+ return `/page/${name}`;
+ }
+
+ function rememberPublished(node, pageName) {
+ const destinationHash = (node?.destination_hash || "").trim();
+ if (!destinationHash || !NOMAD_DESTINATION_HASH.test(destinationHash)) {
+ return;
+ }
+ lastPublished.value = {
+ destinationHash,
+ pagePath: nomadPagePathForName(pageName),
+ pageName: pageName || "index.mu",
+ serverName: node.name || "",
+ };
+ }
+
+ function openPublishedInNomadNet() {
+ const published = lastPublished.value;
+ if (!published?.destinationHash) {
+ return;
+ }
+ showPublishMenu.value = false;
+ pushRoute({
+ name: "nomadnetwork",
+ params: { destinationHash: published.destinationHash },
+ query: {
+ path: published.pagePath || "/page/index.mu",
+ newTab: "1",
+ },
+ });
+ }
+
+ async function offerOpenInNomadNet(pageName, serverName) {
+ if (!lastPublished.value?.destinationHash) {
+ DialogUtils.alert(t("tools.micron_editor.publish_published", { page: pageName, server: serverName }));
+ return;
+ }
+ const open = await DialogUtils.confirm(
+ t("tools.micron_editor.publish_open_nomadnet_confirm", {
+ page: pageName,
+ server: serverName,
+ })
+ );
+ if (open) {
+ openPublishedInNomadNet();
+ } else {
+ ToastUtils.success(t("tools.micron_editor.publish_published", { page: pageName, server: serverName }));
+ }
+ }
+
+ async function createMeshServerAndPublish() {
+ if (publishBusy.value) {
+ return;
+ }
+ const entered = await DialogUtils.prompt(t("tools.micron_editor.publish_create_prompt_name"), "Micron Pages");
+ if (entered === null || !String(entered).trim()) {
+ return;
+ }
+ const serverName = String(entered).trim();
+ publishBusy.value = true;
+ try {
+ const createRes = await window.api.post(apiPath("/page-nodes"), { name: serverName });
+ const created = createRes.data || {};
+ if (!created.node_id) {
+ throw new Error("create_failed");
+ }
+ const running = await ensureNodeRunning(created);
+ pageNodes.value = [...pageNodes.value.filter((n) => n.node_id !== running.node_id), running];
+ await publishToNode(running, { alreadyRunning: true });
+ } catch (e) {
+ showPublishMenu.value = false;
+ DialogUtils.alert(e.response?.data?.message || t("tools.micron_editor.publish_failed_create"));
+ } finally {
+ publishBusy.value = false;
+ }
+ }
+
+ async function publishToNode(node, publishOptions = {}) {
+ if (publishBusy.value && !publishOptions.alreadyRunning) {
+ return;
+ }
+ const tab = getActiveTab();
+ const busyOwned = !publishOptions.alreadyRunning;
+ if (busyOwned) {
+ publishBusy.value = true;
+ }
+ try {
+ let running = node;
+ if (!publishOptions.alreadyRunning) {
+ running = await ensureNodeRunning(node);
+ }
+ const existingPages = await fetchNodePages(running);
+ const pageBase = await resolvePublishPageBase(tab, existingPages, running.name);
+ if (!pageBase) {
+ return;
+ }
+ const publishName = pageBaseWithExtension(pageBase, tab);
+ const response = await window.api.post(apiPath(`/page-nodes/${running.node_id}/pages`), {
+ name: publishName,
+ content: tab.content,
+ });
+ showPublishMenu.value = false;
+ const savedName = response.data?.name || publishName;
+ await uploadImages(running, [tab.content]);
+ rememberPublished(running, savedName);
+ await offerOpenInNomadNet(savedName, running.name);
+ } catch (e) {
+ DialogUtils.alert(
+ e.response?.data?.message ||
+ (e.message === "missing_node"
+ ? t("tools.micron_editor.publish_failed")
+ : t("tools.micron_editor.publish_failed"))
+ );
+ } finally {
+ if (busyOwned) {
+ publishBusy.value = false;
+ }
+ }
+ }
+
+ async function openPublishSite() {
+ showPublishMenu.value = false;
+ showPublishSiteModal.value = true;
+ try {
+ const response = await window.api.get(apiPath("/page-nodes"));
+ pageNodes.value = response.data;
+ } catch {
+ pageNodes.value = [];
+ }
+ }
+
+ function buildSiteIndexPage(destinationHash, pages) {
+ const lines = [`>Links`, ""];
+ for (const page of pages) {
+ const label = String(page.label || page.name || "")
+ .replace(/[`[\]]/g, "")
+ .trim();
+ const pageName = String(page.name || "").trim();
+ if (!pageName) {
+ continue;
+ }
+ lines.push(`[${label || pageName}\`${destinationHash}:/page/${pageName}]`);
+ }
+ lines.push("");
+ return lines.join("\n");
+ }
+
+ async function publishSite(payload) {
+ if (publishBusy.value) {
+ return;
+ }
+ const pages = payload?.pages || [];
+ if (pages.length === 0) {
+ return;
+ }
+ publishBusy.value = true;
+ try {
+ let node = payload.nodeId ? pageNodes.value.find((n) => n.node_id === payload.nodeId) : null;
+ if (!node) {
+ if (!payload.newServerName) {
+ DialogUtils.alert(t("tools.micron_editor.publish_site_no_server"));
+ return;
+ }
+ const createRes = await window.api.post(apiPath("/page-nodes"), { name: payload.newServerName });
+ node = createRes.data || {};
+ if (!node.node_id) {
+ throw new Error("create_failed");
+ }
+ }
+ const running = await ensureNodeRunning(node);
+ pageNodes.value = [...pageNodes.value.filter((n) => n.node_id !== running.node_id), running];
+ let published = 0;
+ let lastSavedName = null;
+ for (const page of pages) {
+ try {
+ const response = await window.api.post(apiPath(`/page-nodes/${running.node_id}/pages`), {
+ name: page.name,
+ content: page.content,
+ });
+ lastSavedName = response.data?.name || page.name;
+ published++;
+ } catch {
+ console.error(`Failed to publish page: ${page.name}`);
+ }
+ }
+ await uploadImages(
+ running,
+ pages.map((page) => page.content)
+ );
+ if (payload.generateIndex && running.destination_hash && published > 0) {
+ try {
+ const indexContent = buildSiteIndexPage(running.destination_hash, pages);
+ const indexRes = await window.api.post(apiPath(`/page-nodes/${running.node_id}/pages`), {
+ name: "index.mu",
+ content: indexContent,
+ });
+ lastSavedName = indexRes.data?.name || "index.mu";
+ } catch {
+ console.error("Failed to publish generated index page");
+ }
+ }
+ showPublishSiteModal.value = false;
+ if (lastSavedName) {
+ rememberPublished(running, lastSavedName);
+ }
+ if (published === 0) {
+ DialogUtils.alert(t("tools.micron_editor.publish_failed"));
+ return;
+ }
+ ToastUtils.success(
+ t("tools.micron_editor.publish_site_done", {
+ published,
+ total: pages.length,
+ server: running.name,
+ })
+ );
+ if (lastPublished.value?.destinationHash) {
+ const open = await DialogUtils.confirm(
+ t("tools.micron_editor.publish_open_nomadnet_confirm", {
+ page: lastSavedName,
+ server: running.name,
+ })
+ );
+ if (open) {
+ openPublishedInNomadNet();
+ }
+ }
+ } catch (e) {
+ DialogUtils.alert(e.response?.data?.message || t("tools.micron_editor.publish_failed"));
+ } finally {
+ publishBusy.value = false;
+ }
+ }
+
+ return {
+ showPublishMenu,
+ showPublishSiteModal,
+ pageNodes,
+ publishBusy,
+ lastPublished,
+ togglePublishMenu,
+ fetchNodePages,
+ tabNameToPageBase,
+ pageBaseWithExtension,
+ isUnsetMicronTabName,
+ resolvePublishPageBase,
+ ensureNodeRunning,
+ nomadPagePathForName,
+ rememberPublished,
+ openPublishedInNomadNet,
+ offerOpenInNomadNet,
+ createMeshServerAndPublish,
+ publishToNode,
+ openPublishSite,
+ buildSiteIndexPage,
+ publishSite,
+ };
+}
diff --git a/tests/frontend/useMicronPublish.test.js b/tests/frontend/useMicronPublish.test.js
new file mode 100644
index 00000000..451035f6
--- /dev/null
+++ b/tests/frontend/useMicronPublish.test.js
@@ -0,0 +1,275 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { useMicronPublish } from "../../meshchatx/src/frontend/js/micron/useMicronPublish.js";
+import DialogUtils from "@/js/DialogUtils";
+import ToastUtils from "@/js/ToastUtils";
+
+const micronEditorT = (key, params = {}) => {
+ const strings = {
+ "tools.micron_editor.new_tab": "New Tab",
+ "tools.micron_editor.publish_prompt_name":
+ 'index.mu already exists on "{server}". Enter a page name (without .mu):',
+ "tools.micron_editor.publish_published": 'Published "{page}" to {server}',
+ "tools.micron_editor.publish_failed": "Failed to publish page",
+ };
+ let out = strings[key] ?? key;
+ for (const [k, v] of Object.entries(params)) {
+ out = out.replace(`{${k}}`, String(v));
+ }
+ return out;
+};
+
+vi.mock("@/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn(),
+ alert: vi.fn(),
+ prompt: vi.fn(),
+ },
+}));
+
+vi.mock("@/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+describe("useMicronPublish", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.api = {
+ get: vi.fn().mockResolvedValue({ data: [] }),
+ post: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ });
+
+ const makePublish = (overrides = {}) =>
+ useMicronPublish({
+ t: micronEditorT,
+ pushRoute: vi.fn(),
+ getActiveTab: () => ({ id: 1, name: "New Tab 1", content: "x" }),
+ uploadImages: vi.fn().mockResolvedValue({ uploaded: 0, failed: 0 }),
+ ...overrides,
+ });
+
+ it("starts with publish state closed and idle", () => {
+ const publish = makePublish();
+ expect(publish.showPublishMenu.value).toBe(false);
+ expect(publish.showPublishSiteModal.value).toBe(false);
+ expect(publish.pageNodes.value).toEqual([]);
+ expect(publish.publishBusy.value).toBe(false);
+ expect(publish.lastPublished.value).toBeNull();
+ });
+
+ it("togglePublishMenu loads page nodes when opening", async () => {
+ window.api.get.mockResolvedValue({ data: [{ node_id: "n1", name: "Srv" }] });
+ const publish = makePublish();
+ await publish.togglePublishMenu();
+ expect(publish.showPublishMenu.value).toBe(true);
+ expect(window.api.get).toHaveBeenCalledWith("/api/v1/page-nodes");
+ expect(publish.pageNodes.value).toEqual([{ node_id: "n1", name: "Srv" }]);
+ await publish.togglePublishMenu();
+ expect(publish.showPublishMenu.value).toBe(false);
+ });
+
+ it("togglePublishMenu falls back to an empty node list on error", async () => {
+ window.api.get.mockRejectedValue(new Error("offline"));
+ const publish = makePublish();
+ await publish.togglePublishMenu();
+ expect(publish.pageNodes.value).toEqual([]);
+ });
+
+ it("isUnsetMicronTabName matches default new tab labels", () => {
+ const publish = makePublish();
+ expect(publish.isUnsetMicronTabName("New Tab 2")).toBe(true);
+ expect(publish.isUnsetMicronTabName("Homepage")).toBe(false);
+ expect(publish.isUnsetMicronTabName("")).toBe(true);
+ });
+
+ it("tabNameToPageBase strips known page extensions", () => {
+ const publish = makePublish();
+ expect(publish.tabNameToPageBase({ name: "About Page.mu" })).toBe("About_Page");
+ expect(publish.tabNameToPageBase({ name: "Landing.html" })).toBe("Landing");
+ expect(publish.tabNameToPageBase({ name: "Notes" })).toBe("Notes");
+ });
+
+ it("pageBaseWithExtension preserves the tab extension", () => {
+ const publish = makePublish();
+ expect(publish.pageBaseWithExtension("landing", { name: "Landing.html" })).toBe("landing.html");
+ expect(publish.pageBaseWithExtension("index", { name: "New Tab 1" })).toBe("index");
+ expect(publish.pageBaseWithExtension(" ", { name: "a.mu" })).toBe("");
+ });
+
+ it("resolvePublishPageBase uses index when server has no index.mu", async () => {
+ const publish = makePublish();
+ await expect(
+ publish.resolvePublishPageBase({ name: "New Tab 1" }, [], "srv")
+ ).resolves.toBe("index");
+ });
+
+ it("resolvePublishPageBase uses tab name when index.mu exists and tab is renamed", async () => {
+ const publish = makePublish();
+ await expect(
+ publish.resolvePublishPageBase({ name: "About Page" }, ["index.mu"], "srv")
+ ).resolves.toBe("About_Page");
+ });
+
+ it("resolvePublishPageBase prompts when index.mu exists and tab name is unset", async () => {
+ DialogUtils.prompt.mockResolvedValue("custom_page");
+ const publish = makePublish();
+ await expect(
+ publish.resolvePublishPageBase({ name: "New Tab 1" }, ["index.mu"], "srv")
+ ).resolves.toBe("custom_page");
+ expect(DialogUtils.prompt).toHaveBeenCalled();
+ });
+
+ it("nomadPagePathForName builds /page/ paths", () => {
+ const publish = makePublish();
+ expect(publish.nomadPagePathForName("about.mu")).toBe("/page/about.mu");
+ expect(publish.nomadPagePathForName("/page/x.mu")).toBe("/page/x.mu");
+ expect(publish.nomadPagePathForName("")).toBe("/page/index.mu");
+ });
+
+ it("buildSiteIndexPage emits micron links for each page", () => {
+ const publish = makePublish();
+ const dest = "d".repeat(32);
+ const content = publish.buildSiteIndexPage(dest, [
+ { name: "about.mu", label: "About" },
+ { name: "news.mu", label: "News [x]" },
+ ]);
+ expect(content).toContain(`[About\`${dest}:/page/about.mu]`);
+ expect(content).toContain(`[News x\`${dest}:/page/news.mu]`);
+ });
+
+ it("rememberPublished records the destination and openPublishedInNomadNet routes", async () => {
+ const pushRoute = vi.fn();
+ const publish = makePublish({ pushRoute });
+ const dest = "a".repeat(32);
+ publish.rememberPublished({ destination_hash: dest, name: "Srv" }, "index.mu");
+ expect(publish.lastPublished.value).toEqual({
+ destinationHash: dest,
+ pagePath: "/page/index.mu",
+ pageName: "index.mu",
+ serverName: "Srv",
+ });
+ publish.openPublishedInNomadNet();
+ expect(pushRoute).toHaveBeenCalledWith({
+ name: "nomadnetwork",
+ params: { destinationHash: dest },
+ query: { path: "/page/index.mu", newTab: "1" },
+ });
+ });
+
+ it("rememberPublished ignores non-hash destinations", () => {
+ const publish = makePublish();
+ publish.rememberPublished({ destination_hash: "nope", name: "Srv" }, "index.mu");
+ expect(publish.lastPublished.value).toBeNull();
+ });
+
+ it("publishToNode posts index.mu and uploads referenced images", async () => {
+ const dest = "a".repeat(32);
+ window.api.get.mockResolvedValue({ data: { pages: [] } });
+ window.api.post.mockResolvedValue({ data: { name: "index.mu" } });
+ DialogUtils.confirm.mockResolvedValue(false);
+ const uploadImages = vi.fn().mockResolvedValue({ uploaded: 1, failed: 0 });
+ const publish = makePublish({ uploadImages });
+ await publish.publishToNode({ node_id: "n1", name: "My Server", running: true, destination_hash: dest });
+ expect(window.api.post).toHaveBeenCalledWith("/api/v1/page-nodes/n1/pages", {
+ name: "index",
+ content: "x",
+ });
+ expect(uploadImages).toHaveBeenCalledWith(
+ expect.objectContaining({ node_id: "n1" }),
+ ["x"]
+ );
+ expect(publish.lastPublished.value?.pageName).toBe("index.mu");
+ expect(publish.publishBusy.value).toBe(false);
+ });
+
+ it("publishToNode starts a stopped node before publishing", async () => {
+ const dest = "c".repeat(32);
+ window.api.get.mockResolvedValue({ data: { pages: [] } });
+ window.api.post
+ .mockResolvedValueOnce({ data: { destination_hash: dest } })
+ .mockResolvedValueOnce({ data: { name: "index.mu" } });
+ DialogUtils.confirm.mockResolvedValue(false);
+ const publish = makePublish();
+ await publish.publishToNode({ node_id: "n3", name: "Stopped", running: false });
+ expect(window.api.post).toHaveBeenNthCalledWith(1, "/api/v1/page-nodes/n3/start");
+ expect(window.api.post).toHaveBeenNthCalledWith(2, "/api/v1/page-nodes/n3/pages", {
+ name: "index",
+ content: "x",
+ });
+ });
+
+ it("publishSite uploads pages in order and writes the index page", async () => {
+ const dest = "e".repeat(32);
+ window.api.post.mockResolvedValue({ data: {} });
+ DialogUtils.confirm.mockResolvedValue(false);
+ const publish = makePublish();
+ publish.pageNodes.value = [{ node_id: "n9", name: "Srv", running: true, destination_hash: dest }];
+ await publish.publishSite({
+ nodeId: "n9",
+ pages: [
+ { name: "one.mu", content: "1", label: "One" },
+ { name: "two.mu", content: "2", label: "Two" },
+ ],
+ generateIndex: true,
+ });
+ const posts = window.api.post.mock.calls.filter((c) => c[0] === "/api/v1/page-nodes/n9/pages");
+ expect(posts.map((c) => c[1].name)).toEqual(["one.mu", "two.mu", "index.mu"]);
+ expect(posts[2][1].content).toContain(`[One\`${dest}:/page/one.mu]`);
+ expect(publish.lastPublished.value?.pageName).toBe("index.mu");
+ expect(publish.showPublishSiteModal.value).toBe(false);
+ });
+
+ it("publishSite creates a new server when no nodeId is given", async () => {
+ const dest = "f".repeat(32);
+ window.api.post
+ .mockResolvedValueOnce({ data: { node_id: "n10", name: "Fresh", running: false } })
+ .mockResolvedValueOnce({ data: { destination_hash: dest } })
+ .mockResolvedValue({ data: {} });
+ DialogUtils.confirm.mockResolvedValue(false);
+ const publish = makePublish();
+ await publish.publishSite({
+ nodeId: null,
+ newServerName: "Fresh",
+ pages: [{ name: "index.mu", content: "home", label: "Home" }],
+ generateIndex: false,
+ });
+ expect(window.api.post).toHaveBeenNthCalledWith(1, "/api/v1/page-nodes", { name: "Fresh" });
+ expect(window.api.post).toHaveBeenNthCalledWith(2, "/api/v1/page-nodes/n10/start");
+ expect(window.api.post).toHaveBeenNthCalledWith(3, "/api/v1/page-nodes/n10/pages", {
+ name: "index.mu",
+ content: "home",
+ });
+ });
+
+ it("createMeshServerAndPublish creates, starts, publishes, and can open NomadNet", async () => {
+ const dest = "b".repeat(32);
+ window.api.get.mockResolvedValue({ data: { pages: [] } });
+ window.api.post
+ .mockResolvedValueOnce({ data: { node_id: "n2", name: "Micron Pages", running: false } })
+ .mockResolvedValueOnce({ data: { destination_hash: dest } })
+ .mockResolvedValueOnce({ data: { name: "index.mu" } });
+ DialogUtils.prompt.mockResolvedValue("Micron Pages");
+ DialogUtils.confirm.mockResolvedValue(true);
+ const pushRoute = vi.fn();
+ const publish = makePublish({ pushRoute });
+ await publish.createMeshServerAndPublish();
+ expect(window.api.post).toHaveBeenNthCalledWith(1, "/api/v1/page-nodes", { name: "Micron Pages" });
+ expect(window.api.post).toHaveBeenNthCalledWith(2, "/api/v1/page-nodes/n2/start");
+ expect(window.api.post).toHaveBeenNthCalledWith(3, "/api/v1/page-nodes/n2/pages", {
+ name: "index",
+ content: "x",
+ });
+ expect(pushRoute).toHaveBeenCalledWith({
+ name: "nomadnetwork",
+ params: { destinationHash: dest },
+ query: { path: "/page/index.mu", newTab: "1" },
+ });
+ });
+});
Served by rngit 1.5.4 - Generated in 0.03s